/*
 * Copyright 2025 coze-dev Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { useEffect, type PropsWithChildren, useRef, useState } from 'react';

import cls from 'classnames';
import { useScroll } from 'ahooks';

interface StickyProps {
  /** Scroll container, that is, the container with scrollHeight greater than viewportHeight and overflow-y auto/scroll */
  scrollContainerRef: () => Element;
  /**
   * The top property when used as a css sticky
   * @default 0
   */
  top?: number;
  /**
   * After triggering sticky, the bottom (for aesthetics) has an extra scroll distance
   * @default 0
   */
  bottom?: number;
}

/**
 * Sticky container component, used to solve the problem that sticky elements cannot be fully exposed when they are higher than the viewport
 *
 * The effect is that after triggering the sticky, the sticky container will follow the scroll of the scrolling container and move up and down limited.
 */
export function Sticky({
  top: stickyTop = 0,
  bottom: stickyBottom = 0,
  scrollContainerRef,
  children,
}: PropsWithChildren<StickyProps>) {
  const stickyContainerRef = useRef<HTMLDivElement>(null);
  /** An invisible element used to detect whether it is sticky by IntersectionObserver */
  const stickyDetectRef = useRef<HTMLDivElement>(null);
  const [isSticky, setIsSticky] = useState(false);
  const prevScrollTop = useRef(scrollContainerRef()?.scrollTop || 0);
  // Sticky container simulates the distance of scrolling up
  const [simulateScrollDistance, setSimulateScrollDistance] = useState(0);

  useEffect(() => {
    if (!stickyDetectRef.current) {
      return;
    }

    /** IntersectionObserver monitor if sticky is triggered */
    const intersectionObserver = new IntersectionObserver(
      ([entry]) => {
        const { isIntersecting } = entry;
        setIsSticky(!isIntersecting);
      },
      { rootMargin: `-${stickyTop}px 0px 0px` },
    );
    intersectionObserver.observe(stickyDetectRef.current);

    return () => {
      intersectionObserver.disconnect();
    };
  }, []);

  // It has been tested that this method can listen to scrolls generated by methods such as'scrollTo ', regardless of whether the mode is smooth or instant.
  useScroll(scrollContainerRef, scrollEvent => {
    /** The distance where the page scrolls up as a whole (scrollbar moves down). If it is negative, it means the page scrolls down. */
    const scrollUpDistance = scrollEvent.top - prevScrollTop.current;
    prevScrollTop.current = scrollEvent.top;

    if (!stickyContainerRef.current || !isSticky) {
      // Return false to avoid useScroll rerender
      // (Other setStates within the callback will trigger rerender normally)
      return false;
    }

    const viewportHeight = window.innerHeight;
    const stickyContainerHeight = stickyContainerRef.current?.scrollHeight || 0;

    /** After triggering sticky, simulate the height of the rolling container */
    const simulateStickyContainerHeight =
      stickyContainerHeight + stickyTop + stickyBottom;
    // Determine whether the height is less than the viewport. If so, it can always be displayed in the view normally, and there is no need to make a mess of calculations later.
    if (simulateStickyContainerHeight < viewportHeight) {
      return false;
    }
    /** The part of the simulated scrolling container higher than the viewport, that is, the upper limit of the simulated scrolling */
    const simulateMaxScrollDistance =
      simulateStickyContainerHeight - viewportHeight;

    if (scrollUpDistance > 0) {
      // #region processing scroll up
      const stickyReachedBottom =
        simulateScrollDistance >= simulateMaxScrollDistance;
      if (stickyReachedBottom) {
        setSimulateScrollDistance(simulateMaxScrollDistance);
        return false;
      }
      setSimulateScrollDistance(
        Math.min(
          simulateScrollDistance + scrollUpDistance,
          simulateMaxScrollDistance,
        ),
      );
      return false;
      // #endregion
    } else {
      // #region processing scroll down
      const stickyReachedTop = simulateScrollDistance <= 0;
      if (stickyReachedTop) {
        setSimulateScrollDistance(0);
        return false;
      }
      setSimulateScrollDistance(
        Math.max(simulateScrollDistance + scrollUpDistance, 0),
      );
      return false;
      // #endregion
    }
  });

  return (
    <div
      ref={stickyContainerRef}
      className={cls('sticky')}
      style={{ top: stickyTop - simulateScrollDistance }}
    >
      <div ref={stickyDetectRef} className="absolute top-[-1px]" />
      {children}
    </div>
  );
}
